# 一、开始

上一篇文章 (opens new window)分析了Webpack打包的原理,其中的loader用来转换非JS文件,这次来分析一个具体且常用的loader——vue-loader

本文会从源码入手,详细分析vue-loader的工作原理,以及说明Scoped CSSCSS Modules的实现原理。

下面是一张流程图:

# 二、前置知识

# 1. Pitching Loader (opens new window)

loader用于对模块的源代码进行转换,因为webpack只能识别JS文件,loader的作用一般是将其他文件转为JS文件。

loader的调用顺序是从右到左,从下到上。但在实际执行loader之前,会从左到右,从上到下调用loaderpitch (opens new window)方法。

下面是官网 (opens new window)的一个例子。

webpack配置如下:

module.exports = {
  // ...
  module: {
    rules: [
      {
        use: ['a-loader', 'b-loader', 'c-loader'],
      }
    ]
  }
}

将发生的步骤为:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

pitching loader的一个作用是可以共享数据。pitch的第三个参数data,会暴露给loaderthis.data,比如下面的例子:

module.exports = function (content) {
  return someSyncOperation(content, this.data.value);
};

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  data.value = 42;
};

另一个作用是当一个loaderpitch方法返回非undefined时,会中断后面loader的执行,比如若上面例子中的b-loader如下:

module.exports = function (content) {
  return someSyncOperation(content)
}

module.exports.pitch = function () {
  return 'export {}'
}

loader的执行顺序会缩减为:

|- a-loader `pitch`
  |- b-loader `pitch` returns a module
|- a-loader normal execution

# 2. 内联loader (opens new window)

loader有两种使用方式:配置方式和内联方式。

  • 配置方式是我们常用的方式,就是在webpack.config.js中使用loader
  • 内联方式是在import/require语句中显示的指定loader

内联方式使用loader的示例如下:

import Styles from 'style-loader!css-loader?modules!./styles.css';

!将多个loader分割,行内loader调用顺序还是从右到左,所以上述语句意思为对./style.css文件依次使用css-loaderstyle-loader处理,并且css-loader传递了参数modules

与上面语句等价的webpack配置如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
          { loader: 'sass-loader' }
        ]
      }
    ]
  }
};

可以为内联import语句增加前缀,来覆盖配置中的loaderpreLoaderpostLoader

  • 使用!前缀,将禁用所有的normal loader(普通loader) import Styles from '!style-loader!css-loader?modules!./styles.css';
  • 使用!!前缀,将禁用所有已配置的loader(preLoaderloaderpostLoader) import Styles from '!!style-loader!css-loader?modules!./styles.css';
  • 使用-!前缀,将禁用所有已配置的preLoaderloader,但不禁用postLoader import Styles from '-!style-loader!css-loader?modules!./styles.css';

# 3. resourceQuery (opens new window)

在配置loader时,大部分时候通过配置test字段,来匹配文件:

{
  test: /\.vue$/,
  loader: 'vue-loader'
}

// 当引入vue后缀文件时,将文件内容传输给vue-loader进行处理
import Foo from './source.vue'

resourceQuery可以根据文件的引用路径参数来匹配文件,当引入文件路径携带query参数匹配时,也将加载该loader

{
  resourceQuery: /vue=true/,
  loader: path.resolve(__dirname, './test-loader.js')
}

// 下面两个文件会经test-loader处理
import './test.js?vue=true'
import Foo from './source.vue?vue=true'

# 三、vue-loader原理

本次分析的版本为v15.9.8 (opens new window)

vue-loader的使用方式如下,注意要同时配置VueLoaderPlugin

// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  module: {
    rules: [
      // ... 
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}

# 1. VueLoaderPlugin

先看下VueLoaderPlugin的作用:

// plugin-webpack4.js
const RuleSet = require('webpack/lib/RuleSet')

class VueLoaderPlugin = {
  apply(compiler) {

    // use webpack's RuleSet utility to normalize user rules
    const rawRules = compiler.options.module.rules
    const { rules } = new RuleSet(rawRules)

    // for each user rule (except the vue rule), create a cloned rule
    // that targets the corresponding language blocks in *.vue files.
    const clonedRules = rules
      .filter(r => r !== vueRule)
      .map(cloneRule)

    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }
    
    // replace original rules
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}
module.exports = VueLoaderPlugin

VueLoaderPlugin先获取了webpack原来的rules,然后创建了pitcher规则,其作用是对query中包含vue的文件,使用./loaders/pitcher中的loader,也就是PitcherLoader

然后为携带了?vue&lang=xx这种query参数的文件,创建和.xx文件一样的规则。比如query中携带?vue&lang=ts,则复制并应用用户为.ts定义的规则,比如ts-loader。这些复制的规则称为clonedRules

然后将[pitcher, ...clonedRules, ...rules]作为新的rules

# 2. 第一阶段

vue-loader的入口文件是lib/index.js,其导出了一个方法。webpack处理vue文件的过程中,会调用两次此方法。

第一次是是通过parse方法,将.vue文件按照teplate/script/style类型分为多个块。

const { parse } = require('@vue/component-compiler-utils')

function loadTemplateCompiler() {
  return require('vue-template-compiler')
}

module.exports = function (source) {
  const {
    resourceQuery = '',
    resourcePath
  } = loaderContext = this;

  const descriptor = parse({
    source,
    compiler: loadTemplateCompiler(loaderContext),
  })

  // template
  let templateImport = `var render, staticRenderFns`
  let templateRequest
  if (descriptor.template) {
    const src = descriptor.template.src || resourcePath
    const idQuery = `&id=${id}`
    const scopedQuery = hasScoped ? `&scoped=true` : ``
    const attrsQuery = attrsToQuery(descriptor.template.attrs)
    const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}`
    const request = templateRequest = stringifyRequest(src + query)
    templateImport = `import { render, staticRenderFns } from ${request}`
  }

  // script
  let scriptImport = `var script = {}`
  if (descriptor.script) {
    const src = descriptor.script.src || resourcePath
    const attrsQuery = attrsToQuery(descriptor.script.attrs, 'js')
    const query = `?vue&type=script${attrsQuery}${inheritQuery}`
    const request = stringifyRequest(src + query)
    scriptImport = (
      `import script from ${request}\n` +
      `export * from ${request}` // support named exports
    )
  }

  // styles
  let stylesCode = ``
  if (descriptor.styles.length) {
    stylesCode = genStylesCode(
      loaderContext,
      descriptor.styles,
      id,
      resourcePath,
      stringifyRequest,
      needsHotReload,
    )
  }
   let code = `
${templateImport}
${scriptImport}
${stylesCode}

/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
  script,
  render,
  staticRenderFns,
  ${hasFunctional ? `true` : `false`},
  ${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
  ${hasScoped ? JSON.stringify(id) : `null`},
  ${isServer ? JSON.stringify(hash(request)) : `null`}
  ${isShadow ? `,true` : ``}
)
  `.trim() + `\n`

  code += `\nexport default component.exports`
  return code
}

@vue/component-compiler-utilsparse方法如下:

function parse(options) {
  const {
    source,
    filename = '',
    compiler,
    compilerParseOptions = { pad: 'line' },
    sourceRoot = '',
    needMap = true
  } = options
  const cacheKey = hash(
    filename + source + JSON.stringify(compilerParseOptions)
  )
  let output = cache.get(cacheKey)
  if (output) return output
  output = compiler.parseComponent(source, compilerParseOptions)
  
  if (needMap) {
    if (output.script && !output.script.src) {
      output.script.map = generateSourceMap()
    }
    if (output.styles) {
      output.styles.forEach(style => {
        if (!style.src) {
          style.map = generateSourceMap()
        }
      })
    }
  }
  cache.set(cacheKey, output)
  return output
}

parse方法先判断是否有缓存,有的话直接返回缓存内容,否则,调用compiler.parseComponent(source)获取output,然后如果需要sourcemap,则在output.scriptoutput.styles中赋值map属性,最后返回output

这里的compiler就是vue-template-compiler导出的compiler方法。

@vue/component-compiler-utils这个库的名称副其实,只是个工具,核心逻辑还是在其他库中,比如这里的vue-template-compiler

上面的compiler.parseComponent方法在vue/src/sfc/parser.js中,其会返回如下结构的对象:

{
  template: null,
  script: null,
  styles: [],
  customBlocks: [],
  errors: []
}

vue-loaderdescriptortemplate/script/style等部分做判断,拼接出各自新的引用路径,其导出的内容示例如下:

import { render, staticRenderFns } from "./empty-state.vue?vue&type=template&id=619de588&scoped=true&"
import script from "./empty-state.vue?vue&type=script&lang=js&"
export * from "./empty-state.vue?vue&type=script&lang=js&"
import style0 from "./empty-state.vue?vue&type=style&index=0&id=619de588&scoped=true&lang=scss&"

/* normalize component */
import normalizer from "!../../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  "619de588",
  null
  
)

export default component.exports

# 3. 第二阶段

上面讲到的VueLoaderPlugin中,如果文件query中包含vue,则会应用pitcherLoader

第一阶段导出的内容带有vue参数,会触发pitcherLoader的调用。看下pitcherLoader的源码:

const templateLoaderPath = require.resolve('./templateLoader')
const isPitcher = l => l.path !== __filename
const isPreLoader = l => !l.pitchExecuted
const isPostLoader = l => l.pitchExecuted

module.exports = code => code

module.exports.pitch = function () {
  const query = qs.parse(this.resourceQuery.slice(1))
  let loaders = this.loaders

  // remove self
  loaders = loaders.filter(isPitcher)

  const genRequest = loaders => {
    const seen = new Map()
    const loaderStrings = []

    loaders.forEach(loader => {
      const identifier = typeof loader === 'string'
        ? loader
        : (loader.path + loader.query)
      const request = typeof loader === 'string' ? loader : loader.request
      if (!seen.has(identifier)) {
        seen.set(identifier, true)
        loaderStrings.push(request)
      }
    })

    return loaderUtils.stringifyRequest(this, '-!' + [
      ...loaderStrings,
      this.resourcePath + this.resourceQuery
    ].join('!'))
  }

  if (query.type === `style`) {
    const cssLoaderIndex = loaders.findIndex(isCSSLoader)
    if (cssLoaderIndex > -1) {
      const afterLoaders = loaders.slice(0, cssLoaderIndex + 1)
      const beforeLoaders = loaders.slice(cssLoaderIndex + 1)
      const request = genRequest([
        ...afterLoaders,
        stylePostLoaderPath,
        ...beforeLoaders
      ])
      return query.module
        ? `export { default } from  ${request}; export * from ${request}`
        : `export * from ${request}`
    }
  }

  if (query.type === `template`) {
    const preLoaders = loaders.filter(isPreLoader)
    const postLoaders = loaders.filter(isPostLoader)

    const request = genRequest([
      ...cacheLoader,
      ...postLoaders,
      templateLoaderPath + `??vue-loader-options`,
      ...preLoaders
    ])
    return `export * from ${request}`
  }
  
  const request = genRequest(loaders)
  return `import mod from ${request}; export default mod; export * from ${request}`
}

pitcherLoadernoraml loader部分未做任何操作,直接返回了之前的code,所以核心在pitch方法上。

由于pitch方法返回了非undefined,且它是第一个loader,所以会跳过之后的loader

pitcherLoader的作用是将之前文件的query,根据参数type,替换成相应的带loaderquery,也就是上面提到的内联loader

对于type=template文件的引用,比如:

import { render, staticRenderFns } from "./create-team-dialog.vue?vue&type=template&id=127d8294&scoped=true&"

pitcherLoader会将该文件的引用替换为:

export * from "-!../../../../../../node_modules/cache-loader/dist/cjs.js?{\"cacheDirectory\":\"node_modules/.cache/vue-loader\",\"cacheIdentifier\":\"2f391a00-vue-loader-template\"}!../../../../../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./create-team-dialog.vue?vue&type=template&id=127d8294&scoped=true&"

行内loader的执行顺序是从右到左,也就是依次执行vue-loadercache-loadertemplateLoadercache-loader

下面是一个typestyle的文件转化结果:

import mod from "-!../../../../../node_modules/vue-style-loader/index.js??ref--6-oneOf-1-0!../../../../../node_modules/css-loader/dist/cjs.js??ref--6-oneOf-1-1!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-2!../../../../../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-3!../../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./gp.vue?vue&type=style&index=0&id=6e34f811&scoped=true&lang=css&";
export default mod;
export * from "/*引用地址同上*/"

对于上述样式文件,loader的执行顺序为:vue-loadercache-loaderpostcss-loader * 2vue-loader/stylePostLoadercss-loadervue-style-loader

对于type=script的文件来说,会走到最后一个逻辑,把原来的loaders通过genRequest转为request参数,覆盖之前的文件引用。

const request = genRequest(loaders)
return `import mod from ${request}; export default mod; export * from ${request}`

pitcherLoaderquery参数中含vue的文件,进行了上述引用的替换,替换后会依次调用query上的内联loader。内联的第一个loadervue-loader,所以会再次调用vue-loader

# 4. 第三阶段

这一次vue-loader做的事情比较简单,仅是根据query.type去执行下一个loader

module.exports = function (source) {
  const incomingQuery = qs.parse(resourceQuery.slice(1))
  if (!incomingQuery.type) {
    // 第二次进入vue-loader时
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
    )
  }
}

function selectBlock() {
  if (query.type === `template`) {
    if (appendExtension) {
      loaderContext.resourcePath += '.' + (descriptor.template.lang || 'html')
    }
    loaderContext.callback(
      null,
      descriptor.template.content,
      descriptor.template.map
    )
    return
  }
  if (query.type === `script`) {
  }
  if (query.type === `style`) {
  }
  if (query.type === `custom`) {
  }
  //...
}

对于type=template的部分来说,pitcherLoader中插入了templateLoader,该loaderlib/loaders/templateLoader.js中:

const { compileTemplate } = require('@vue/component-compiler-utils')

module.exports = function (source) {
  // ...
  const compiled = compileTemplate(finalOptions)
  const { code } = compiled
  return code + `\nexport { render, staticRenderFns }`
}

templateLoader调用了@vue/component-compiler-utilscompileTemplate方法,返回一个对象,包含了render方法,其实这就是vue-loader最核心的功能,即把组件转为render函数。

下面是一个例子:

var render = function() {
	var _vm = this;
	var _h = _vm.$createElement;
	var _c = _vm._self._c || _h;
	return _c('div', {
		staticClass: "wrap"
	},
	[_c('a', {
		staticClass: "tip-toc-commbtn tip-btn-primary",
		on: {
			"click": function($event) {
				$event.stopPropagation();
				return _vm.enterGame($event)
			}
		}
	},
	[_vm._v("进入游戏")])])
}
var staticRenderFns = []
export { render, staticRenderFns }

对于type=style的部分来说,pitcherLoader中插入了stylePostLoader,该loaderlib/loaders/stylePostLoader.js中:

const { compileStyle } = require('@vue/component-compiler-utils')

module.exports = function (source, inMap) {
  const query = qs.parse(this.resourceQuery.slice(1))
  const { code, map, errors } = compileStyle({
    source,
    filename: this.resourcePath,
    id: `data-v-${query.id}`,
    map: inMap,
    scoped: !!query.scoped,
    trim: true
  })

  if (errors.length) {
    this.callback(errors[0])
  } else {
    this.callback(null, code, map)
  }
}

stylePostLoader调用了compileStyle,它的一个作用是,对含有scoped属性style中的属性选择器前加上[data-v-hash]

style部分后面会经过style-loader添加到head中,或者通过miniCssExtractPlugin提取到一个公共的css文件中。

获取到scriptrenderstaticRenderFns,在运行时会调用normalizeComponent,返回component,其包含optionsexports

# 四、总结

下面总结下vue-loader的工作流程:

  1. .vue文件分割成template/script/styles三个部分
  2. template部分经过pitcherLoadertemplateLoader,最终会通过compile生成renderstaticRenderFns
  3. 获取script部分,命名为script,在后面的normalizeComponent中会用到,并导出script
  4. styles部分经过pitcherLoaderstylePostLoader,最终会通过css-loadervue-style-loader添加到head中,或者通过css-loaderminiCssExtractPlugin提取到一个公共的css文件中。
  5. 使用vue-loadernormalizeComponent方法,合并scriptrender和staticRenderFns,返回component,其包含optionsexports

看下scoped css工作流程:

  1. vue-loader在处理.vue文件的template部分时,会根据文件路径和文件内容生成hash值。
  2. 如果.vue文件中有scopedstyle标签,则生成一个scopedId,形如data-v-hash,这里的hash就是上面的hash值。
  3. 对于vue中的style部分,vue-loader会在css-loader前增加自己的stylePostLoaderstylePostLoader会给每个选择器增加属性[data-v-hash],然后通过style-loadercss添加到head中,或通过miniCssExtractPlugincss提取成单独的文件。
  4. vue-loadernormalizeComponent方法,判断如果vue文件中有scopedstyle,则其返回的options._scopeId为上面的scopedId.
  5. 上面的_scopedIdvnode渲染生成 DOM 的时候会在dom元素上增增加scopedId,也就是增加data-v-hash

经过上面的过程,实现了CSS的模块私有化。

另外简要说一下css modules原理:

  1. vue-loader在处理.vue文件时,遇到含有modulestyle标签,会在生成的code中注入injectStyles方法,该方法会执行this["a"] = (style0.locals || style0)或者this["$style"] = (style1.locals || style1),从而在vue文件中可以使用this.$style.class0等引入模块化的类和id
  2. css-loadervue文件中style部分解析,导出locals属性,将原来的类名和id转为唯一的值。
  3. normalizeComponent中判断如果含有injectStyles,则会将render方法包装成含有injectStylesrenderWithStyleInjection方法。vue实例化的时候,会首先执行injectStyles方法,然后执行原来的render方法。这样vue实例上就能拿到$style的类名和id了,也就实现了CSS的模块化。

# 五、相关资料

  1. vue-loader 深入学习 (opens new window)
  2. 一文读懂 vue-loader 原理 (opens new window)
  3. 深入 vue-loader 原理 (opens new window)
  4. 从vue-loader源码分析CSS Scoped的实现 (opens new window)
  5. css-loader style-loader原理探究 (opens new window)
  6. less-loader、css-loader、style-loader实现原理 (opens new window)
  7. webpack的几个常见loader源码浅析 (opens new window)